Runtime detection of .NET deserialization RCE (CVE-2025-53770 “ToolShell” and the wider ysoserial.net
gadget family) via two independent channels:
- AMSI provider (primary) — a native x64 COM DLL registered under
HKLM\SOFTWARE\Microsoft\AMSI\Providers. EveryAssembly.Load(byte[])in an AMSI-instrumented host (w3wp.exe, PowerShell 5.1, Office VBA,iisexpress.exe) reaches ourIAntimalwareProvider::Scan, we parse the incoming PE metadata withIMetaDataDispenserEx::OpenScopeOnMemory, and matchTypeRef/AssemblyRef/TypeDef/TypeSpecagainst a JSON rulebase mechanically derived fromysoserial.net. Hits are appended to%ProgramData%\CVEonDeserializationFinder\hits.logand the scanned buffer is dumped to a per-Scan subfolder for offline triage. - CLR profiler (secondary) — an in-process
ICorProfilerCallback3profiler that reads module metadata viaIMetaDataImport2to cover the one blind spot AMSI cannot see:XmlSerializer/Reflection.Emitdynamic assemblies (Microsoft.GeneratedCode) have no PE image and never reach the CLR→AMSI hook. It reuses the same controller, named pipe and rulebase as the AMSI channel, forwarding a metadata-record read onClassLoadFinished— where the gadgetTypeRefs (ObjectDataProvider,LosFormatter,ExpandedWrapper) first become readable on the dynamic serializer module. It is wired end-to-end (SPIKE-A → P6) and driven by a one-command launcher; see the CLR profiler channel section below anddocs/profiler-integration-plan.md.
src/
CVEonDeserializationFinder.AmsiProvider/ native x64 COM DLL, C++17
dllmain.cpp exports + CLSID registration
provider.{h,cpp} IAntimalwareProvider::Scan
metadata_scanner.{h,cpp} ECMA-335 metadata reader
rules.{h,cpp} JSON matcher
config.{h,cpp} registry-driven configuration
logger.{h,cpp} JSON-line hits.log + per-Scan dumps
CVEonDeserializationFinder.AmsiProvider.def exports
third_party/json.hpp nlohmann/json 3.11.3 header
CVEonDeserializationFinder.AmsiController/ .NET 4.8 CLI ("AV engine" + installer)
Program.cs Main + Spectre.Console verb dispatch
Commands/ one class per verb (install-amsi, listen,
install-profiler, uninstall-profiler, ...)
Analysis/ PipeProtocol, MetadataScanner, RulesEngine,
HitsLogWriter, ConfigKeys
CVEonDeserializationFinder.NativeProfiler/ native x64 ICorProfilerCallback3, C++17
SpikeProfiler.cpp ClassLoadFinished metadata probe (dynamic modules)
pipe_client.{h,cpp} v3 metadata-record serializer + named-pipe client
ProfilerCallbackStubs.inl generated S_OK callback stubs
CVEonDeserializationFinder.NativeProfiler.def exports
CVEonDeserializationFinder.Tester/ .NET 4.8 harness
CVEonDeserializationFinder.Tester.cs --bf / --xml / --los / --load / --hold
CVEonDeserializationFinder.PayloadGenerator/ .NET 4.8 CLI
CVEonDeserializationFinder.PayloadGenerator.cs benign + malicious verbs
Benign/ 4 safe generators (one per sink)
Malicious/YsoserialWrapper.cs ysoserial.exe wrapper
scripts/ install/uninstall/debug/trigger + profiler launcher PS1
docs/ CFP-EN.md, CFP-RU.md, profiler-integration-plan.md
rules/gadgets.json 32 rules, staged into ProgramData on install
CFPs/ offline CFP snapshots (Sessionize)
third_party/ysoserial.net/ git submodule
The companion repo ../WebApplication/ hosts the intentionally vulnerable
ASP.NET Web Forms lab that the AMSI provider protects during validation.
Full x64 build from a Developer PowerShell (Visual Studio 2022, Build Tools 18 or Pro/Enterprise):
$msbuild = 'C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\amd64\MSBuild.exe'
# One-time restore for the ysoserial.net submodule (malicious generator wrapper).
& $msbuild third_party\ysoserial.net\ysoserial.sln -t:Restore -p:RestorePackagesConfig=true
# Full solution build.
& $msbuild CVEonDeserializationFinder.sln -p:Configuration=Release -p:Platform=x64Outputs of interest:
| Artifact | Path |
|---|---|
| AMSI provider DLL (~175 KB) | src\CVEonDeserializationFinder.AmsiProvider\bin\x64\Release\CVEonDeserializationFinder.AmsiProvider.dll |
| Controller CLI | src\CVEonDeserializationFinder.AmsiController\bin\x64\Release\CVEonDeserializationFinder.AmsiController.exe |
| Native profiler (SPIKE-A) | src\CVEonDeserializationFinder.NativeProfiler\bin\x64\Release\CVEonDeserializationFinder.NativeProfiler.dll |
| Tester harness | src\CVEonDeserializationFinder.Tester\bin\Debug\CVEonDeserializationFinder.Tester.exe |
| Payload generator | src\CVEonDeserializationFinder.PayloadGenerator\bin\Debug\CVEonDeserializationFinder.PayloadGenerator.exe |
| ysoserial.exe | third_party\ysoserial.net\ysoserial\bin\Release\ysoserial.exe |
The controller stages a permanent copy of the provider DLL into
%ProgramData%\CVEonDeserializationFinder\provider\ and registers COM/AMSI against that path.
Rebuilds after the initial install only replace the file on disk — registration stays intact and
regsvr32 does not need to run again unless you moved the ProgramData tree.
# One-shot install (right-click "Run as administrator"):
scripts\install-elevated.ps1
# Or manually from an elevated console:
$exe = 'src\CVEonDeserializationFinder.AmsiController\bin\x64\Release\CVEonDeserializationFinder.AmsiController.exe'
& $exe install-amsi # stages DLL + rules and registers
& $exe configure-scope 'w3wp.exe;iisexpress.exe;powershell.exe;CVEonDeserializationFinder.Tester.exe'
& $exe enforce off # log-only mode (default)
& $exe dump on # per-Scan assembly dump (default)
& $exe info # show current stateThe controller supports the following verbs:
| Verb | Purpose |
|---|---|
install-amsi [--provider <src>] [--rules-dir <src>] |
Copy DLL + rules to ProgramData, register COM + AMSI keys, seed defaults. |
uninstall-amsi [--provider <path>] |
Unregister COM + AMSI keys. Does not delete logs / dumps. |
install-profiler [--pool <NAME>] [--recycle] |
Deploy + COM-register the CLR profiler; attach it to an IIS pool (COR_*). |
uninstall-profiler [--pool <NAME>] [--unregister] |
Detach the profiler from a pool; --unregister also drops the CLSID. |
configure-scope <exe1;exe2;...> |
Set the process allow-list (semicolon-separated). |
enforce <on|off> |
Toggle AMSI_RESULT_DETECTED emission. Default off. |
debug <on|off> |
Toggle per-Scan diagnostic lines in hits.log. |
dump <on|off> |
Toggle per-Scan assembly capture into dumps\. |
info |
Show current registration + config + filesystem state. |
tail-hits [--follow] [--path <file>] |
Print / tail the JSON-line hits log. |
listen [--ui <live|plain>] |
Run as the AV engine: accept pipe connections, match rules, live UI. |
| Key | Type | Purpose |
|---|---|---|
HKLM\SOFTWARE\Classes\CLSID\{C0E3B7D2-...}\InprocServer32 |
REG_SZ | Points at %ProgramData%\CVEonDeserializationFinder\provider\CVEonDeserializationFinder.AmsiProvider.dll |
HKLM\SOFTWARE\Microsoft\AMSI\Providers\{C0E3B7D2-...} |
key | Registers the provider with the AMSI subsystem |
HKLM\SOFTWARE\CVEonDeserializationFinder\AllowedProcesses |
REG_SZ | Semicolon-separated allow-list, lowercase-compared |
HKLM\SOFTWARE\CVEonDeserializationFinder\EnforceMode |
DWORD | 0 = log only, 1 = return AMSI_RESULT_DETECTED |
HKLM\SOFTWARE\CVEonDeserializationFinder\DebugScanLog |
DWORD | 0 = quiet, 1 = per-Scan info line in provider.log |
HKLM\SOFTWARE\CVEonDeserializationFinder\DumpAssemblies |
DWORD | 0 = off, 1 = save every matched buffer to disk |
HKLM\SOFTWARE\CVEonDeserializationFinder\DumpMaxSize |
DWORD | Skip dump if buffer > N bytes (default 64 MiB) |
HKLM\SOFTWARE\CVEonDeserializationFinder\DumpFolderBase |
REG_SZ | Root for per-Scan dump subfolders |
HKLM\SOFTWARE\CVEonDeserializationFinder\RulesPath |
REG_SZ | Override rulebase path (default is next to the DLL) |
HKLM\SOFTWARE\CVEonDeserializationFinder\HitsLogPath |
REG_SZ | Override hits.log path |
HKLM\SOFTWARE\CVEonDeserializationFinder\ProviderLogPath |
REG_SZ | Override provider diagnostics log (default provider.log, separate from hits.log) |
HKLM\SOFTWARE\CVEonDeserializationFinder\InboundDumpFolder |
REG_SZ | Controller dump-first folder; every buffer received over the pipe is saved here (default inbound\) |
Three artefacts under %ProgramData%\CVEonDeserializationFinder\ capture every scan, correlated
by a per-request id that the provider stamps and the controller echoes back:
provider.log— provider-side diagnostics (one line per Scan whenDebugScanLog=1), kept separate fromhits.log. Each line carriesreq=<id>, the image name, buffer size, and whether the managed-PE forward to the controller returnedstatus=ok.inbound\— controller dump-first store: every buffer received over the pipe is written to disk before rules run, as<ts>_<pid>_<proc>_<reqid>_<sha16>.binplus a.jsonsidecar (requestId,pid,process,size,sha256). This proves byte-for-byte delivery regardless of whether a rule fired.dumps\— provider-side fallback evidence, written only when the controller pipe is unavailable, so nothing is lost while the listener is down.
provider.log {"level":"info","msg":"scan req=247699353894913 proc=w3wp.exe size=3072 kind=managed"}
{"level":"info","msg":"pipe req=247699353894913 status=ok verdict=1 hits=1 ..."}
inbound\ 20260717-153307.412_57672_w3wp.exe_247699353894913_e0e6fe31c7db5a93.bin
20260717-153307.412_57672_w3wp.exe_247699353894913.json
hits.log {"level":"hit","pid":57672,"process":"w3wp.exe","hits":[{"id":"GADGET-ObjectDataProvider",...}]}
The same requestId (247699353894913 above) appears in all three, so one scan is traceable end
to end: provider → controller → verdict.
The PayloadGenerator emits safe base64 (used by the web-app "Load benign example" button) and
wraps ysoserial.exe for malicious payload construction inside authorised lab environments:
$pg = 'src\CVEonDeserializationFinder.PayloadGenerator\bin\Debug\CVEonDeserializationFinder.PayloadGenerator.exe'
# Emit one benign payload for each sink to stdout (labelled).
& $pg demo
# Emit one benign payload for a specific sink.
& $pg benign --sink bf --output - # BinaryFormatter, base64 to stdout
& $pg benign --sink xml --output benign_xml.b64 # XmlSerializer(ToolShell) → file
& $pg benign --sink los --output benign_los.b64
& $pg benign --sink osf --output benign_osf.b64
# Malicious wrapper (authorised lab only): builds a ysoserial payload with ping ya.ru as the RCE
# proof-of-life, base64 stdout.
& $pg malicious --sink osf --cmd "ping ya.ru -n 10" --output payload_osf.b64The malicious wrapper maps sink codes to ysoserial -g / -f:
| Sink | Gadget | Formatter |
|---|---|---|
| bf | TypeConfuseDelegate |
BinaryFormatter |
| xml | ObjectDataProvider |
Xaml |
| los | ActivitySurrogateSelector |
LosFormatter |
| osf | ObjectDataProvider |
ObjectStateFormatter |
Pre-built benign base64 examples for direct paste into the WebApplication “Load benign example”
button live in docs/benign-payloads.md and are regenerated by
PayloadGenerator demo.
The ../WebApplication repo hosts a two-tab ASP.NET Web Forms page bound to
http://localhost:8088 (port 8080 is reserved on this workstation by gontlm-proxy). Tab 2
(“Deserialization Lab”) exposes the four sinks, a payload textbox, a Load benign example
button, a Deserialize button, and a child-process table that highlights ping.exe,
cmd.exe, powershell.exe, mshta.exe, etc. spawned by the current w3wp.exe — the canonical
proof of successful exploitation.
Bring the site up (elevated):
scripts\install-iis.ps1 # enable full IIS with ASP.NET 4.5
scripts\configure-iis-site.ps1 # create AppPool + site on http://localhost:8088The AMSI channel only ever sees a managed PE — every Assembly.Load(byte[]) carries a real PE
image the CLR hands to IAntimalwareProvider::Scan. A real XmlSerializer / Reflection.Emit
serializer, however, is materialised as a dynamic assembly (Microsoft.GeneratedCode) with no PE
image, so it never reaches the CLR→AMSI hook. The profiler closes exactly that gap: it attaches to
the CLR inside w3wp.exe, reads the dynamic module's metadata via IMetaDataImport2 on
ClassLoadFinished, and forwards a v3 metadata-record to the same controller, named pipe and
rulebase as the AMSI channel. Hits it produces carry "source":"profiler" (plus "dynamic":true and
the module name) so the two channels stay distinguishable in hits.log.
One-command launcher (elevated). start-profiler-detection.ps1 self-elevates and brings the
whole channel up — it does not touch the AMSI provider:
powershell -ExecutionPolicy Bypass -File scripts\start-profiler-detection.ps1
# optional: -Pool <name> -Port <n> -Rebuild -IisResetIt (1) starts the controller in a visible live-UI window (listen --ui live), (2) runs
install-profiler --pool CVEDeserializationLab (stage the DLL into ProgramData, COM-register the
profiler CLSID, set the per-pool COR_* env vars), and (3) recycles the pool so a fresh w3wp
regenerates its serializer assemblies under the profiler. Trigger a dynamic serializer path and
watch the controller window / hits.log:
Invoke-WebRequest http://localhost:8088/api/deserialize.ashx -Method POST -UseBasicParsing `
-ContentType 'application/x-www-form-urlencoded' -Body 'sink=xml&payload=AAAAAA=='
# profiler hits carry "source":"profiler"Tear down with stop-profiler-detection.ps1 — detach by default (removes the per-pool env, recycles
the pool, stops the controller; the CLSID stays registered for a fast re-start), or -Full to also
unregister the CLSID:
powershell -ExecutionPolicy Bypass -File scripts\stop-profiler-detection.ps1 # detach
powershell -ExecutionPolicy Bypass -File scripts\stop-profiler-detection.ps1 -Full # + unregisterManual verbs (what the launcher wraps):
$exe = 'src\CVEonDeserializationFinder.AmsiController\bin\x64\Release\CVEonDeserializationFinder.AmsiController.exe'
& $exe install-profiler --pool CVEDeserializationLab --recycle # deploy + register + attach
& $exe uninstall-profiler --pool CVEDeserializationLab # detach (CLSID left)
& $exe uninstall-profiler --pool CVEDeserializationLab --unregister # detach + drop CLSIDinstall-profiler sets three per-pool environment variables via appcmd so the profiler loads in
the pool's next w3wp:
| Variable | Value |
|---|---|
COR_ENABLE_PROFILING |
1 |
COR_PROFILER |
{D44ADB37-8AAD-4583-8023-129CCE9ABFA9} (profiler CLSID, ≠ AMSI provider) |
COR_PROFILER_PATH |
%ProgramData%\CVEonDeserializationFinder\provider\...NativeProfiler.dll |
The scripted end-to-end proof is scripts/atomic-profiler-test.ps1
— it builds, attaches, drives a dynamic serializer path in w3wp.exe, and asserts a
source=profiler hit; it ends PASS.
rules/gadgets.json is a JSON array validated against rules/gadgets.schema.json (draft-07). One
entry per gadget shape:
Matching semantics — all listed conditions must be satisfied:
typeRefs— every entry must appear inTypeRef ∪ TypeDef ∪ TypeSpec(name-only match;XmatchesX<...>via a suffix rule).assemblyRefsAny— at least one entry must be present inAssemblyRef.typeDefsContainsAny— at least one substring must appear in aTypeDefname (optional heuristic).
Two one-pass scripts build, deploy, start the controller, fire an exploit, and verify the whole evidence trail in a single run — re-runnable verbatim each iteration:
scripts/atomic-test.ps1— PowerShell host. Builds provider + controller, redeploys the DLL into ProgramData, starts one listener, spawns a freshpowershell.exethatAssembly.Loads a marker assembly referencingObjectDataProvider+LosFormatter, then assertsprovider.loggrew, aninbound\dump appeared, and aGADGET-ObjectDataProviderhit landed. The first elevated run also grants the interactive user Modify on the ProgramData tree so later runs need no elevation.scripts/atomic-iis-test.ps1— IIS /w3wp.exehost (elevated). Recycles the app pool onhttp://localhost:8088, primes the site, POSTssink=markerto/api/deserialize.ashx, and verifies the same trail withproc=w3wp.exe.scripts/atomic-profiler-test.ps1— CLR profiler channel (elevated). Builds controller + profiler, attaches the profiler to the app pool, drives a dynamicXmlSerializerpath inw3wp.exe, and asserts asource=profilerhit lands (five hits incl.GADGET-ObjectDataProvider). EndsPASS.
# PowerShell host (first run elevated to grant ACL, later runs plain):
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\atomic-test.ps1
# IIS / w3wp host (elevated — recycles the app pool):
Start-Process powershell -Verb RunAs -Wait -ArgumentList `
'-NoProfile','-ExecutionPolicy','Bypass','-File','scripts\atomic-iis-test.ps1'Both end in a PASS line when the provider → controller → hit chain is intact.
Assembly.Load(byte[]) only reaches AMSI in hosts that initialised the AMSI subsystem. Windows
PowerShell 5.1 qualifies out of the box:
$hits = 'C:\ProgramData\CVEonDeserializationFinder\hits.log'
Remove-Item $hits -ErrorAction SilentlyContinue
# WPF assembly contains ObjectDataProvider TypeDef → several heuristic rules fire.
& 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' -NoProfile -Command @'
$b = [IO.File]::ReadAllBytes('C:\Windows\Microsoft.NET\Framework64\v4.0.30319\WPF\PresentationFramework.dll')
[Reflection.Assembly]::Load($b) | Out-Null
'@
Get-Content $hitsFull end-to-end (managed-PE gadget load → IIS/w3wp → AMSI hit + dump):
# The scripted path is scripts\atomic-iis-test.ps1 (recycles the pool, POSTs sink=marker,
# verifies the w3wp trail). To drive it by hand instead, with a listener already running:
Invoke-WebRequest http://localhost:8088/api/deserialize.ashx -Method POST -UseBasicParsing `
-ContentType 'application/x-www-form-urlencoded' -Body 'sink=marker&payload=AAAAAA=='
# Watch:
# - provider.log gains scan proc=w3wp.exe kind=managed + pipe req=... status=ok verdict=1
# - inbound\ gets a fresh <ts>_<pid>_w3wp.exe_<reqid>.bin + .json
# - hits.log gains a "level":"hit" with rule GADGET-ObjectDataProvider
& $exe tail-hits --follow # elevatedNote: a real XmlSerializer ToolShell payload materialises its serializer as a dynamic assembly (
Microsoft.GeneratedCode, no PE image), which the CLR AMSI hook does not scan. AMSI sees the gadget only when a managed PE is loaded viaAssembly.Load(byte[])— see Caveats.
- AMSI host requirement. Plain .NET Framework console apps do not initialise AMSI; the CLR does
not scan
Assembly.Load(byte[])for them. Real targets (w3wp.exe,powershell.exe5.1, Office VBA hosts) do. The Tester harness contains an explicitAmsiInitialize+AmsiScanBufferwrapper to emulate this locally. - XmlSerializer helper is dynamic. The serializer assembly that
XmlSerializergenerates for the ToolShell type (List<ExpandedWrapper<LosFormatter, ObjectDataProvider>>) is emitted with Reflection.Emit asMicrosoft.GeneratedCode— a dynamic assembly with no PE image — so the CLR AMSI hook never scans it. Detection fires on the managed PE a gadget chain ultimately loads viaAssembly.Load(byte[]); themarkerlab sink andatomic-*.ps1scripts reproduce exactly that byte[] load. - Dynamic-assembly coverage (CLR profiler). The secondary channel closes the XmlSerializer
blind spot with an
ICorProfilerCallback3profiler activated per-app-pool (COR_ENABLE_PROFILING/COR_PROFILER/COR_PROFILER_PATH). SPIKE-A pinned two design-driving facts: the dynamic module's gadgetTypeRefs are empty atModuleLoadFinishedand only appear atClassLoadFinished(moduleMicrosoft.GeneratedCode), and aICorProfilerCallback2-only profiler is rejected by the .NET Framework 4.x CLR (event0x2517) — so the profiler must implementICorProfilerCallback3. The gadget signal (ObjectDataProvider,LosFormatter,ExpandedWrapper) is carried inTypeRefs and is observable at serializer-construction time, before the payload is parsed. Wired end-to-end and driven byscripts\start-profiler-detection.ps1; design + phases indocs/profiler-integration-plan.md. - Unsigned DLL. On systems with strict AMSI-provider signing (some server SKUs, WDAC policies), the provider DLL must be Authenticode-signed to load. For lab / dev use, unsigned load succeeded on Windows 11 26100 alongside Kaspersky KES.
- Heuristic false positives. Some heuristic rules match legitimate framework DLLs (e.g. XAML
gadget rules trigger on
PresentationFramework.dll). The precise ToolShell rule does not have this false positive — it requires the closed generic TypeRef set only produced by the CVE-2025-53770 payload shape. - Web-app is intentionally vulnerable. Do not deploy
WebApplicationanywhere reachable from an untrusted network. It is bound tohttp://localhost:8088only; keep it that way.
README_RU.md— Russian translation, structure-locked to this file.AGENTS.md— automated-agent contract (build commands, mirror rule, layout rules).docs/CFP-EN.md— call-for-papers reference for Code Blue / DefCamp / Black Alpaca / PumaHat / Black Hat EU / Black Hat MEA 2026.docs/CFP-RU.md— Russian mirror.
{ "id": "GADGET-ExpandedWrapper-LosFormatter-ObjectDataProvider", "gadget": "ExpandedWrapper<LosFormatter, ObjectDataProvider>", "severity": "critical", "description": "ToolShell / CVE-2025-53770 payload shape.", "source": "ysoserial.net/Generators/ObjectDataProviderGenerator.cs", "match": { "typeRefs": ["ExpandedWrapper", "LosFormatter", "ObjectDataProvider"], "assemblyRefsAny": [ "System.Data.Services", "System.Web", "PresentationFramework", ], "typeDefsContainsAny": [], }, }